Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

page.tsx35 kB
/** * Hansard Search Page - Search parliamentary debates and speeches * Fully bilingual with Quebec French support */ 'use client'; import { useState, useMemo, useEffect } from 'react'; import { useQuery, NetworkStatus } from '@apollo/client'; import { useTranslations, useLocale } from 'next-intl'; import { useSearchParams } from 'next/navigation'; import { Header } from '@/components/Header'; import { Footer } from '@/components/Footer'; import { Loading } from '@/components/Loading'; import { Card, Button } from '@canadagpt/design-system'; import { SEARCH_HANSARD, SEARCH_MPS, GET_RECENT_STATEMENTS } from '@/lib/queries'; import { Link } from '@/i18n/navigation'; import { getMPPhotoUrl } from '@/lib/utils/mpPhotoUrl'; import { Search, Calendar, User, MessageSquare, Filter, TrendingUp, Copy, ExternalLink, ChevronDown, ChevronUp, Sparkles, Clock, Hash } from 'lucide-react'; import { useBilingualContent } from '@/hooks/useBilingual'; import { usePageThreading } from '@/contexts/UserPreferencesContext'; import { ThreadToggle, ConversationThread } from '@/components/hansard'; export default function HansardPage() { const t = useTranslations('hansard'); const locale = useLocale(); const searchParams = useSearchParams(); // Threading state const { enabled: threadedViewEnabled, setEnabled: setThreadedViewEnabled } = usePageThreading(); // Search state const [searchQuery, setSearchQuery] = useState(''); const [activeQuery, setActiveQuery] = useState(''); // Empty = default view const [showFilters, setShowFilters] = useState(false); const [expandedSpeech, setExpandedSpeech] = useState<string | null>(null); // Pagination state const [hasMore, setHasMore] = useState(true); const STATEMENTS_PER_PAGE = 10; // Filter state const [selectedParty, setSelectedParty] = useState<string>(''); const [selectedMP, setSelectedMP] = useState<string>(''); const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }); const [minWordCount, setMinWordCount] = useState<number>(0); const [documentType, setDocumentType] = useState<string>(''); const [statementType, setStatementType] = useState<string>(''); const [onlySubstantive, setOnlySubstantive] = useState(false); // Initialize from URL parameters useEffect(() => { const query = searchParams.get('q'); const mp = searchParams.get('mp'); const party = searchParams.get('party'); const docType = searchParams.get('docType'); const stmtType = searchParams.get('statementType'); const excludeProcedural = searchParams.get('excludeProcedural'); const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); if (query) { setSearchQuery(query); setActiveQuery(query); } if (mp) setSelectedMP(mp); if (party) setSelectedParty(party); if (docType) setDocumentType(docType); if (stmtType) setStatementType(stmtType); if (excludeProcedural === 'true') setOnlySubstantive(true); if (startDate) setDateRange(prev => ({ ...prev, start: startDate })); if (endDate) setDateRange(prev => ({ ...prev, end: endDate })); // Show filters if any are set if (mp || party || docType || stmtType || excludeProcedural || startDate || endDate) { setShowFilters(true); } }, [searchParams]); // Fetch recent statements (default view) const { data: defaultStatementsData, loading: defaultLoading, fetchMore, networkStatus } = useQuery(GET_RECENT_STATEMENTS, { variables: { limit: STATEMENTS_PER_PAGE, offset: 0, }, skip: activeQuery !== '', // Only fetch when not searching notifyOnNetworkStatusChange: true, // Required for fetchMore to trigger re-renders }); // Distinguish between initial loading and "load more" loading // NetworkStatus.loading = initial load, NetworkStatus.fetchMore = pagination const isInitialLoading = networkStatus === NetworkStatus.loading; const isFetchingMore = networkStatus === NetworkStatus.fetchMore; // Fetch search results // TODO: Add language parameter once backend supports it: language: locale const { data: hansardData, loading: hansardLoading, refetch } = useQuery(SEARCH_HANSARD, { variables: { query: activeQuery, limit: 100, }, skip: activeQuery === '', // Only fetch when actively searching }); // Fetch MPs for autocomplete const { data: mpsData } = useQuery(SEARCH_MPS, { variables: { current: true, limit: 500 }, }); // Track if we have more statements to load // Initial load - assume we have more if we got a full page useEffect(() => { if (defaultStatementsData?.statements && activeQuery === '') { const currentLength = defaultStatementsData.statements.length; console.log('[useEffect] defaultStatementsData updated, length:', currentLength); // If we have at least one full page, assume there might be more setHasMore(currentLength >= STATEMENTS_PER_PAGE); } }, [defaultStatementsData, activeQuery]); // Filter results based on advanced filters const filteredResults = useMemo(() => { // Use default statements if not searching, otherwise use search results const sourceData = activeQuery === '' ? (defaultStatementsData?.statements || []) : (hansardData?.searchHansard || []); console.log('[filteredResults] Source data length:', sourceData.length); console.log('[filteredResults] First 3 items:', sourceData.slice(0, 3).map((s: any) => ({ id: s.id, time: s.time, date: s.partOf?.date }))); console.log('[filteredResults] Last 3 items:', sourceData.slice(-3).map((s: any) => ({ id: s.id, time: s.time, date: s.partOf?.date }))); if (!sourceData.length) return []; let results = [...sourceData]; // Party filter if (selectedParty) { results = results.filter(speech => speech.madeBy?.party === selectedParty ); } // MP filter if (selectedMP) { results = results.filter(speech => speech.madeBy?.id === selectedMP ); } // Date range filter if (dateRange.start) { results = results.filter(speech => speech.partOf?.date >= dateRange.start ); } if (dateRange.end) { results = results.filter(speech => speech.partOf?.date <= dateRange.end ); } // Word count filter if (minWordCount > 0) { results = results.filter(speech => (speech.wordcount || 0) >= minWordCount ); } // Document type filter if (documentType) { results = results.filter(speech => speech.partOf?.document_type === documentType ); } // Statement type filter if (statementType) { results = results.filter(speech => speech.statement_type === statementType ); } // Procedural filter if (onlySubstantive) { results = results.filter(speech => !speech.procedural); } return results; }, [hansardData, defaultStatementsData, activeQuery, selectedParty, selectedMP, dateRange, minWordCount, documentType, statementType, onlySubstantive]); // Handle search const handleSearch = () => { if (searchQuery.trim()) { setActiveQuery(searchQuery); setHasMore(true); } }; // Handle popular topic click const handleTopicClick = (query: string) => { setSearchQuery(query); setActiveQuery(query); setHasMore(true); }; // Handle load more for default view const handleLoadMore = async () => { console.log('=== handleLoadMore clicked ==='); console.log('activeQuery:', activeQuery); console.log('hasMore:', hasMore); console.log('defaultStatementsData:', defaultStatementsData); if (activeQuery !== '') { console.log('Skipping: activeQuery is set'); return; } if (!hasMore) { console.log('Skipping: no more data'); return; } try { const currentLength = defaultStatementsData?.statements?.length || 0; console.log('Current length:', currentLength); console.log('Fetching with offset:', currentLength); // Find the first visible item ID before fetching const firstVisibleElement = document.querySelector('[data-speech-id]'); const anchorId = firstVisibleElement?.getAttribute('data-speech-id'); const scrollY = window.scrollY; console.log('Anchor ID before fetch:', anchorId); console.log('Scroll position before fetch:', scrollY); const result = await fetchMore({ variables: { limit: STATEMENTS_PER_PAGE, offset: currentLength, }, }); console.log('fetchMore result:', result); console.log('Result data:', result.data); // result.data.statements contains ONLY the new items fetched // The Apollo cache merge happens automatically // If we got fewer results than requested, we've reached the end const itemsFetched = result.data?.statements?.length || 0; console.log('Items fetched from server:', itemsFetched); // Check the cache data immediately after fetchMore console.log('defaultStatementsData after fetchMore:', defaultStatementsData?.statements?.length); if (itemsFetched < STATEMENTS_PER_PAGE) { console.log('Setting hasMore to false - got fewer items than requested'); setHasMore(false); } else { console.log('Fetched full page, might have more data'); } // Scroll back to the anchor element after React re-renders requestAnimationFrame(() => { requestAnimationFrame(() => { if (anchorId) { const anchorElement = document.querySelector(`[data-speech-id="${anchorId}"]`); if (anchorElement) { anchorElement.scrollIntoView({ block: 'start', behavior: 'instant' }); console.log('Scrolled back to anchor:', anchorId); } else { // Fallback to scroll position window.scrollTo(0, scrollY); console.log('Anchor not found, restored scroll position to:', scrollY); } } else { window.scrollTo(0, scrollY); console.log('No anchor, restored scroll position to:', scrollY); } }); }); } catch (error) { console.error('Error loading more statements:', error); } }; // Handle copy quote const handleCopyQuote = (speech: any) => { const quote = `"${speech.content_en}"\n\n— ${speech.who_en}, ${new Date(speech.partOf?.date).toLocaleDateString()}`; navigator.clipboard.writeText(quote); }; // Get unique parties from results const availableParties = useMemo(() => { const parties = new Set<string>(); hansardData?.searchHansard?.forEach((speech: any) => { if (speech.madeBy?.party) parties.add(speech.madeBy.party); }); return Array.from(parties).sort(); }, [hansardData]); // Get unique document types const availableDocTypes = useMemo(() => { const types = new Set<string>(); hansardData?.searchHansard?.forEach((speech: any) => { if (speech.partOf?.document_type) types.add(speech.partOf.document_type); }); return Array.from(types).sort(); }, [hansardData]); // Stats const stats = useMemo(() => { const totalWords = filteredResults.reduce((sum, speech) => sum + (speech.wordcount || 0), 0); const uniqueSpeakers = new Set(filteredResults.map(s => s.who_en)).size; const dateRange = filteredResults.length > 0 ? { earliest: Math.min(...filteredResults.map(s => new Date(s.partOf?.date || 0).getTime())), latest: Math.max(...filteredResults.map(s => new Date(s.partOf?.date || 0).getTime())), } : null; return { totalSpeeches: filteredResults.length, totalWords, uniqueSpeakers, dateRange }; }, [filteredResults]); return ( <div className="min-h-screen flex flex-col"> <Header /> <main className="flex-1 page-container"> {/* Hero Section */} <div className="mb-8"> <div className="flex items-center gap-3 mb-3"> <MessageSquare className="h-10 w-10 text-accent-red" /> <div> <h1 className="text-4xl font-bold text-text-primary">{t('title')}</h1> <p className="text-text-secondary">{t('subtitle')}</p> </div> </div> </div> {/* Search Bar */} <Card className="mb-6"> <div className="space-y-4"> {/* Main Search */} <div className="flex gap-2"> <div className="flex-1 relative"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-text-tertiary" /> <input type="text" placeholder={t('search.placeholder')} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="w-full pl-10 pr-4 py-2.5 text-lg bg-bg-secondary border border-border-subtle rounded-lg text-text-primary placeholder-text-tertiary focus:border-accent-red focus:outline-none transition-colors" /> </div> <Button onClick={handleSearch} disabled={!searchQuery.trim()}> {t('search.button')} </Button> <Button variant="secondary" onClick={() => setShowFilters(!showFilters)} > <Filter className="h-4 w-4 mr-2" /> {t('search.filters')} {showFilters ? <ChevronUp className="h-4 w-4 ml-2" /> : <ChevronDown className="h-4 w-4 ml-2" />} </Button> <ThreadToggle enabled={threadedViewEnabled} onChange={setThreadedViewEnabled} size="md" /> </div> {/* Example Searches - hidden for now, would need translation */} {/* TODO: Add example searches with translation support */} {/* Advanced Filters */} {showFilters && ( <div className="pt-4 border-t border-border-subtle space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {/* Party Filter */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.party')} </label> <select value={selectedParty} onChange={(e) => setSelectedParty(e.target.value)} className="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-text-primary" > <option value="">{t('filters.allParties')}</option> {availableParties.map(party => ( <option key={party} value={party}>{party}</option> ))} </select> </div> {/* MP Filter */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.member')} </label> <select value={selectedMP} onChange={(e) => setSelectedMP(e.target.value)} className="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-text-primary" > <option value="">{t('filters.allMPs')}</option> {mpsData?.searchMPs?.map((mp: any) => ( <option key={mp.id} value={mp.id}> {mp.name} ({mp.party}) </option> ))} </select> </div> {/* Document Type */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.documentType')} </label> <select value={documentType} onChange={(e) => setDocumentType(e.target.value)} className="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-text-primary" > <option value="">{t('filters.allTypes')}</option> {availableDocTypes.map(type => ( <option key={type} value={type}>{type}</option> ))} </select> </div> {/* Statement Type */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> Statement Type </label> <select value={statementType} onChange={(e) => setStatementType(e.target.value)} className="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-text-primary" > <option value="">All Types</option> <option value="interjection">Interjections Only</option> <option value="question">Questions Only</option> <option value="answer">Answers Only</option> <option value="debate">Debates Only</option> </select> </div> {/* Date Range Start */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.dateFrom')} </label> <input type="date" value={dateRange.start} onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })} className="w-full px-3 py-2 bg-bg-secondary border border-border-subtle rounded-lg text-text-primary focus:border-accent-red focus:outline-none transition-colors" /> </div> {/* Date Range End */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.dateTo')} </label> <input type="date" value={dateRange.end} onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })} className="w-full px-3 py-2 bg-bg-secondary border border-border-subtle rounded-lg text-text-primary focus:border-accent-red focus:outline-none transition-colors" /> </div> {/* Min Word Count */} <div> <label className="block text-sm font-medium text-text-secondary mb-2"> {t('filters.minWords')} </label> <input type="number" min="0" step="50" value={minWordCount} onChange={(e) => setMinWordCount(parseInt(e.target.value) || 0)} placeholder="0" className="w-full px-3 py-2 bg-bg-secondary border border-border-subtle rounded-lg text-text-primary placeholder-text-tertiary focus:border-accent-red focus:outline-none transition-colors" /> </div> </div> {/* Checkbox Filters */} <div className="flex items-center gap-4"> <label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer"> <input type="checkbox" checked={onlySubstantive} onChange={(e) => setOnlySubstantive(e.target.checked)} className="rounded border-border-subtle" /> {t('filters.onlySubstantive')} </label> </div> {/* Clear Filters */} <div className="flex justify-end"> <Button variant="secondary" size="sm" onClick={() => { setSelectedParty(''); setSelectedMP(''); setDateRange({ start: '', end: '' }); setMinWordCount(0); setDocumentType(''); setStatementType(''); setOnlySubstantive(false); }} > {t('filters.clearAll')} </Button> </div> </div> )} </div> </Card> {/* Popular Topics */} <div className="mb-6"> {/* TODO: Add popular topics with translation support */} </div> {/* Search Stats */} {!hansardLoading && filteredResults.length > 0 && ( <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <Card className="text-center"> <div className="text-3xl font-bold text-accent-red">{stats.totalSpeeches}</div> <div className="text-sm text-text-secondary">{t('results.stats.speeches')}</div> </Card> <Card className="text-center"> <div className="text-3xl font-bold text-accent-red">{stats.uniqueSpeakers}</div> <div className="text-sm text-text-secondary">{t('results.stats.speakers')}</div> </Card> <Card className="text-center"> <div className="text-3xl font-bold text-accent-red"> {(stats.totalWords / 1000).toFixed(1)}k </div> <div className="text-sm text-text-secondary">{t('results.stats.words')}</div> </Card> <Card className="text-center"> <div className="text-3xl font-bold text-accent-red"> {stats.dateRange ? Math.ceil((stats.dateRange.latest - stats.dateRange.earliest) / (1000 * 60 * 60 * 24)) : 0} </div> <div className="text-sm text-text-secondary">{t('results.stats.days')}</div> </Card> </div> )} {/* Results */} <Card> <div className="mb-4"> <h2 className="text-2xl font-bold text-text-primary"> {activeQuery ? t('results.title') : t('results.recentDebates')} {filteredResults.length > 0 && ( <span className="text-text-tertiary ml-2">({filteredResults.length})</span> )} </h2> {activeQuery && ( <p className="text-sm text-text-secondary mt-1"> {t('results.showingFor')} <span className="font-semibold text-text-primary">"{activeQuery}"</span> </p> )} </div> {(isInitialLoading || hansardLoading) ? ( <Loading /> ) : filteredResults.length === 0 ? ( <div className="text-center py-12"> <MessageSquare className="h-16 w-16 text-text-tertiary mx-auto mb-4" /> <h3 className="text-xl font-semibold text-text-primary mb-2">{t('results.noResults')}</h3> <p className="text-text-secondary mb-4"> {t('results.tryDifferent')} </p> <Button onClick={() => handleTopicClick('government')}> {t('results.viewRecent')} </Button> </div> ) : threadedViewEnabled ? ( <ConversationThread statements={filteredResults} defaultExpanded={false} /> ) : ( <div className="space-y-4"> {filteredResults.map((speech: any) => { const isExpanded = expandedSpeech === speech.id; const bilingualSpeech = useBilingualContent(speech); // Use HTML content directly - it's from official government sources const content = bilingualSpeech.content || ''; // For preview, truncate HTML at a reasonable length // Note: This is a simple truncation and may cut in the middle of a tag // but we'll use full content when expanded const preview = content.length > 500 ? content.substring(0, 500) + '...' : content; // Get photo URL from GCS or fallback to ID-based construction const photoUrl = speech.madeBy ? getMPPhotoUrl(speech.madeBy) : null; return ( <div key={speech.id} data-speech-id={speech.id} className="p-4 rounded-lg bg-bg-elevated border border-border-subtle hover:border-accent-red/30 transition-colors" > {/* Header */} <div className="flex items-start justify-between mb-3"> <div className="flex items-center gap-3"> {photoUrl && ( <img src={photoUrl} alt={speech.madeBy.name} className="w-12 h-12 rounded-full object-cover" /> )} <div> {speech.madeBy ? ( <Link href={`/mps/${speech.madeBy.id}` as any} className="font-semibold text-text-primary hover:text-accent-red transition-colors" > {speech.madeBy.name} </Link> ) : ( <span className="font-semibold text-text-primary"> {bilingualSpeech.who} </span> )} <div className="flex items-center gap-2 text-sm text-text-secondary"> {speech.madeBy?.party && ( <span className="font-medium">{speech.madeBy.party}</span> )} {speech.partOf?.date && (() => { // Parse date properly - handle both string dates and timestamps const dateValue = speech.partOf.date; let date: Date; if (typeof dateValue === 'string') { // If it's a string like "2024-10-15", parse it directly date = new Date(dateValue); } else if (typeof dateValue === 'number') { // If it's a number, it's likely a timestamp // Check if it's in seconds (Unix timestamp) or milliseconds date = dateValue > 9999999999 ? new Date(dateValue) : new Date(dateValue * 1000); } else { date = new Date(dateValue); } // Validate the date is reasonable (between 1990 and 2050) const year = date.getFullYear(); const isValidDate = !isNaN(date.getTime()) && year >= 1990 && year <= 2050; return isValidDate ? ( <> <span>•</span> <span className="flex items-center gap-1"> <Calendar className="h-3 w-3" /> {date.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', { year: 'numeric', month: 'long', day: 'numeric' })} </span> </> ) : null; })()} {speech.wordcount && ( <> <span>•</span> <span className="flex items-center gap-1"> <Hash className="h-3 w-3" /> {speech.wordcount} {locale === 'fr' ? 'mots' : 'words'} </span> </> )} </div> </div> </div> {/* Actions */} <div className="flex gap-2"> <button onClick={() => handleCopyQuote(speech)} className="p-2 hover:bg-bg-overlay rounded-lg transition-colors" title={t('results.copyQuote')} > <Copy className="h-4 w-4 text-text-tertiary" /> </button> </div> </div> {/* Topic/Context */} {(bilingualSpeech.h1 || bilingualSpeech.h2 || bilingualSpeech.h3) && ( <div className="mb-2 space-y-1"> {bilingualSpeech.h1 && ( <div className="text-sm font-semibold text-accent-red"> {bilingualSpeech.h1} </div> )} {bilingualSpeech.h2 && ( <div className="text-sm font-medium text-text-primary"> {bilingualSpeech.h2} </div> )} {bilingualSpeech.h3 && ( <div className="text-sm text-text-secondary"> {bilingualSpeech.h3} </div> )} </div> )} {/* Content */} <div className="mb-3"> <div className="text-text-primary prose prose-sm max-w-none"> {(isExpanded ? content : preview).split('\n\n').map((paragraph: string, idx: number) => ( paragraph.trim() && ( <p key={idx} className="mb-2 last:mb-0"> {paragraph} </p> ) ))} </div> {content.length > 500 && ( <button onClick={() => setExpandedSpeech(isExpanded ? null : speech.id)} className="text-sm text-accent-red hover:text-accent-red-hover font-medium mt-2" > {isExpanded ? t('results.showLess') : t('results.readMore')} </button> )} </div> {/* Footer */} <div className="flex items-center justify-between pt-3 border-t border-border-subtle"> <div className="flex items-center gap-3 text-xs text-text-tertiary"> {speech.statement_type && ( <span className="px-2 py-1 bg-bg-overlay rounded"> {speech.statement_type} </span> )} {speech.procedural && ( <span className="px-2 py-1 bg-bg-overlay rounded text-text-tertiary"> {t('results.procedural')} </span> )} {speech.partOf?.document_type && ( <span className="px-2 py-1 bg-bg-overlay rounded"> {speech.partOf.document_type} </span> )} </div> {speech.partOf?.id && ( <Link href={`/debates/${speech.partOf.id}` as any} className="text-sm text-accent-red hover:text-accent-red-hover font-medium flex items-center gap-1" > {t('results.viewFullDebate')} <ExternalLink className="h-3 w-3" /> </Link> )} </div> </div> ); })} </div> )} {/* Load More Button - only shown in default view */} {(() => { const shouldShow = !activeQuery && filteredResults.length > 0 && hasMore; console.log('Load More button visibility:', { activeQuery, resultsLength: filteredResults.length, hasMore, isFetchingMore, shouldShow }); return shouldShow && ( <div className="mt-6 text-center"> <Button onClick={handleLoadMore} variant="secondary" disabled={isFetchingMore} > {isFetchingMore ? t('results.loading') : t('results.loadMore')} </Button> </div> ); })()} </Card> {/* Search Tips */} <Card className="mt-8 bg-bg-overlay border-accent-red/20"> <div className="flex items-start gap-4"> <div className="p-2 bg-accent-red/10 rounded-lg"> <Sparkles className="h-6 w-6 text-accent-red" /> </div> <div className="flex-1"> <h3 className="font-semibold text-text-primary mb-2"> {t('tips.title')} </h3> <ul className="text-sm text-text-secondary space-y-1"> {t.raw('tips.items').map((tip: string, index: number) => ( <li key={index}>• {tip}</li> ))} </ul> </div> </div> </Card> </main> <Footer /> </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server